iT邦幫忙

2023 iThome 鐵人賽

DAY 29
0
Software Development

Haskell 從入門到放棄系列 第 29

[Haskell 從入門到放棄] Day 29 - Monad (6)

  • 分享至 

  • xImage
  •  

State Monad

可能有讀者看到 state monad 這個名字時可能會想,Haskell 這樣的語言允許我們擁有 state 這種感覺應該是 mutable 的東西存在嗎?當然不,但是我們還是可以透過 State Monad 來處理這種關於狀態的問題。

那我們可能要先釐清一下什麼是 state ,就我自己的認知我認為 state 就是會根據 「現在」 的值與我 「現在的」 輸入進行運算後產生一個 「新的」 值。那在 Haskell 中我們一般的變數中當然不會有所謂的「現在的」以及「新的」的差異。 所以們可以藉由 State 來幫助我們

newtype State s a = State { runState :: s -> (a,s) }

instance Monad (State s) where  
    return x = State $ \s -> (x,s)  
    (State h) >>= f = State $ \s -> let (a, newState) = h s  
                                        (State g) = f a  
                                    in  g newState

State 是用 newtype 將所謂的狀態定義為 s -> (a,s) ,意思是狀態不過是一個 function 然後會傳入狀態 s 最後回傳一個值與新的狀態 (a, s) 這個 turple,運用這種特性我們就能不斷地把 s 也就是狀態保持住並帶到下一次 monadic 操作。

至於 Monad 的實作,return 就是回傳一個預設狀態,這個 function 用來建立一個 State 計算,它不修改狀態,只回傳值 xState $ \s -> (x, s) 創建一個 function,該f unction 接受狀態 s 並返回值 x 和相同的狀態 s

>>= 就比較複雜了點,我們將之前的 h 拿來跟現在的狀態值 s 進行計算

還記得嗎,State h 不過就是一個 function

藉此來算出一個值 a以及新的狀態值 newState

這個 a 通常會是 State 計算後的結果,像是 stack 在 pop 時會順便回傳原本最上面的那個值

然後將 fa 進行運算來獲得新的 monadic value (State g 、狀態、function)

提醒一下 f 的型別是 a → mb

最後再將 gnewState 進行運算,來獲得我們最後的結果

總之,>>= 的工作是將先前的 State h 計算的結果 a 再與 f 結合,得到一個新的 State g,該計算在 newState 上運行。

我們來稍微比較一下有無使用 State 的情況

countNumbers :: State Int Int
countNumbers = do
  n <- get  
  put (n + 1)  
  return n

countNumbersWithoutState :: Int -> (Int, Int)
countNumbersWithoutState count = (count, count + 1)

let initialCount = 0
  
  let (result1, finalState1) = runState countNumbers initialCount
  let (result2, finalState2) = runState countNumbers finalState1
  putStrLn $ "State Monad Result 1: " ++ show result1 ++ ", Count 1: " ++ show finalState1
  putStrLn $ "State Monad Result 2: " ++ show result2 ++ ", Count 2: " ++ show finalState2

  let (result3, state3) = countNumbersWithoutState initialCount
  let (result4, state4) = countNumbersWithoutState state3
  putStrLn $ "Without State Monad Result 3: " ++ show result3 ++ ", Count 3: " ++ show state3
  putStrLn $ "Without State Monad Result 4: " ++ show result4 ++ ", Count 4: " ++ show state4

乍看之下差不多而且 State 的做法感覺更囉唆了點,那這樣子的好處是什麼?目前來看最大的好處是,我們對於上一個狀態值及新的狀態值的管理我們不用自己手動去建立一個 tuple 去處理,以 countNumbersWithoutState 這個 function 來說,我們是手動的方式去強制 tuple 第一個元素一定是上一個狀態值,第二個元素一定是新的狀態值。在只有一個 function 下固然沒什麼差,但只要有更多操作這個 tuple 的 function 出現,這件事情就開始變得有點煩人了。

但如果是使用 State 我們不用自己去煩惱這件事情,因為根據 State 的定義只要我去執行 runState 我就會獲得 s → (a ,s) ,那只要我這個 function 最後是回傳 State 那我就不用再手動去處理 tuple的順序之類的問題,只要直接使用 return 幫我 wrap context 就好。

至於 getput 就是去對 State 這個 context 的 monadic 操作,一個是取值一個是更新。

那除此之外呢?還記得 Monad 其中一個好處就是可以串聯 monadic 操作嗎?

let (prevState3, state3) = runState (countNumbers >> countNumbers  >> countNumbers) state2
print $ "State Monad PrevState 3: " ++ show prevState3 ++ ", Count 3: " ++ show state3

這邊我們就可以簡單的一次執行三次 countNumbers ,但如果是countNumbersWithoutState 我還要去煩惱型別是 Int → (Int , Int) ,這樣子我在每次串接時都要去抽出第二個元素當作下一次計算的參數。

這邊我們是使用 >> 來進行 monadic 操作 ,它與 >>= 的差異就是 >> 不會將 monadic 操作的結果往後傳而 >>= 會 ,但因為我們的 countNumbers 其實是沒有參數傳入的他就只是單純執行一個 side effect ,也就是將目前 context 也就是 State 進行變更而已,所以就不需要使用 >>=


今天的程式碼:
https://github.com/toddLiao469469/30days-for-haskell


上一篇
[Haskell 從入門到放棄] Day 27 - Monad (5)
下一篇
[Haskell 從入門到放棄] Day 30 - 開始也是結束
系列文
Haskell 從入門到放棄30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

1
shootingstar
iT邦新手 4 級 ‧ 2023-10-15 21:34:38

補充

想當然爾,那些Reader(我還以為第30天會提)、WriterState monad等我們通常不會自己實作,它們都放在transformersmtl裡,不僅函式庫的名字有點奇怪,它們的實作也不是newtype而是type synonym,諸如ReaderTWriterTStateT之類的,這類XxxT命名方式的data type通常都是Monad transformer。

Monad transformer的作用在於能夠在程式裡方便地組合不同功能的Monad,例如有兩個function定義如下:

data Config = Config
    { databaseSettings :: DatabaseSettings
    , ...
    }

data User = User ... deriving (Show)

connectDatabase :: DatabaseSettings -> IO Connection

findUserById :: Connection -> Int -> IO (Maybe User)

然後我想寫一個searchUser的function,能夠結合Reader(程式的全域設定從這裡讀取)、LoggingT(搜尋使用者時寫一些log)和Either(找不到使用者時回傳錯誤訊息)三個Monad的功能,就可以使用Monad transformer來組合它們。

searchUser :: Int -> ReaderT Config (LoggingT (ExceptT String IO)) User
searchUser userId = do
    $(logDebug) "Search user"

    config <- ask

    maybeUser <- liftIO $ do
        conn <- connectDatabase (databaseSettings config)
        findUserById conn userId

    case maybeUser of
        Just user -> return user
        Nothing -> throwError "No user"

使用起來會需要依據transformer包裹的順序再一層層解開:

main :: IO ()
main = do
    let config =  Config { databaseSettings = ..., ... }
    eitherUser <- runExceptT $ runStdoutLoggingT $ runReaderT (searchUser 1) config

    bimapM_ putStrLn print eitherUser

searchUser的type也還有另外一種寫法,我們稱作tagless final或是mtl style:

 searchUser :: (MonadIO m, MonadReader Config m, MonadLogger m, MonadError String m) => Int -> m User

其實就是不要寫死data type,改成type class而已,相當於OOP的依賴介面而非實作,實際的data type會由function的使用方來決定,function內部的實作只有依賴type class裡提供的function而已,算是最簡單易懂的design pattern。

感謝大大的補充!!
其實主要是因為我還無法理解 mtl 所以就乾脆跳過沒寫了xD

但感覺這樣子才是真正在開發上會用到的形式?

我要留言

立即登入留言